import 'vcf_reserved.dart';

class BreakEnd {
  String MateDirection;
  String Join;
  String Replacement;
  String MatePosition;
  toString() {
    var char = MateDirection == 'left' ? ']' : '[';
    if (this.Join == 'left') {
      return '$char${this.MatePosition}$char${this.Replacement}';
    }
    return '${this.Replacement}${char}${this.MatePosition}${char}';
  }
}

class VCF {
  bool strict;
  BreakEnd breakEnd;
  Map metadata;
  List samples;

  VCF({
    String header,
    this.strict = true,
  }) {
    List<String> headerLines = header.split(RegExp('[\r\n]+')).where((line) => line != null && line.length > 0).toList();
    if (headerLines.length == 0) {
      throw Exception('no non-empty header lines specified');
    }

    // allow access to the Breakend class in case anybody wants to use it for checking
    this.breakEnd = BreakEnd();

    this.metadata = {
      'INFO': vcfReserved['InfoFields'],
      'FORMAT': vcfReserved['GenotypeFields'],
      'ALT': vcfReserved['AltTypes'],
      'FILTER': vcfReserved['FilterTypes'],
    };
    headerLines.forEach((line) {
      if (!line.startsWith('#')) {
        throw Exception('Bad line in header:\n$line');
      }
      if (line.startsWith('##')) {
        this._parseMetadata(line);
      } else if (line != null && line.length > 0) {
        var fields = line.split('\t');
        var thisHeader = fields.sublist(0, 8);
        var correctHeader = [
          '#CHROM',
          'POS',
          'ID',
          'REF',
          'ALT',
          'QUAL',
          'FILTER',
          'INFO',
        ];
        if (fields.length < 8) {
          throw Exception('VCF header missing columns:\n$line');
        } else if (fields.length == 9) {
          throw new Exception('VCF header has FORMAT but no samples:\n$line');
        } else if (thisHeader.length != correctHeader.length || !thisHeader.every((element) => correctHeader.contains(element))) {
          throw Exception('VCF column headers not correct:\n$line');
        }
        this.samples = fields.sublist(9);
      }
    });
    if (this.samples == null) throw new Exception('VCF does not have a header line');
  }

  /// Parse a VCF metadata line (i.e. a line that starts with "##") and add its
  /// properties to the object.
  /// @param {string} line - A line from the VCF. Supports both LF and CRLF
  /// newlines.
  _parseMetadata(String line) {
    Match match = RegExp('^##(.+?)=(.*)').firstMatch(line.trim());
    String metaKey = match.group(1);
    String metaVal = match.group(2);
    if (metaVal.startsWith('<')) {
      if (!(this.metadata.containsKey(metaKey))) {
        this.metadata[metaKey] = {};
      }
      List list = this._parseStructuredMetaVal(metaVal);
      var id = list[0], keyVals = list[1];
      this.metadata[metaKey][id] = keyVals;
    } else {
      this.metadata[metaKey] = metaVal;
    }
  }

  /// Parse a VCF header structured meta string (i.e. a meta value that starts
  /// with "<ID=...")
  /// @param {string} metaVal - The VCF metadata value
  ///
  /// @returns {Array} - Array with two entries, 1) a string of the metadata ID
  /// and 2) an object with the other key-value pairs in the metadata
  _parseStructuredMetaVal(String metaVal) {
    Map keyVals = this._parseKeyValue(metaVal.replaceAll(RegExp('^<|>\$'), ''), ',');
    var id = keyVals['ID'];
    keyVals.remove('ID');
    // delete keyVals.ID
    if (keyVals.containsKey('Number')) {
      keyVals[Number] = num.tryParse(keyVals['Number']);
    }
    return [id, keyVals];
  }

  /// Sometimes VCFs have key-value strings that allow the separator within
  /// the value if it's in quotes, like:
  /// 'ID=DB,Number=0,Type=Flag,Description="dbSNP membership, build 129"'
  ///
  /// Parse this at a low level since we can't just split at "," (or whatever
  /// separator). Above line would be parsed to:
  /// {ID: 'DB', Number: '0', Type: 'Flag', Description: 'dbSNP membership, build 129'}
  /// @param {string} str - Key-value pairs in a string
  /// @param {string} [pairSeparator] - A string that separates sets of key-value
  /// pairs
  ///
  /// @returns {object} An object containing the key-value pairs
  Map _parseKeyValue(str, [pairSeparator = ';']) {
    var data = {};
    var currKey = '';
    var currValue = '';
    var state = 1; // states: 1: read key to = or pair sep, 2: read value to sep or quote, 3: read value to quote
    for (var i = 0; i < str.length; i += 1) {
      if (state == 1) {
        // read key to = or pair sep
        if (str[i] == '=') {
          state = 2;
        } else if (str[i] != pairSeparator) {
          currKey += str[i];
        } else if (currValue == '') {
          data[currKey] = null;
          currKey = '';
        }
      } else if (state == 2) {
        // read value to pair sep or quote
        if (str[i] == pairSeparator) {
          data[currKey] = currValue;
          currKey = '';
          currValue = '';
          state = 1;
        } else if (str[i] == '"') {
          state = 3;
        } else
          currValue += str[i];
      } else if (state == 3) {
        // read value to quote
        if (str[i] != '"')
          currValue += str[i];
        else
          state = 2;
      }
    }
    if (state == 2 || state == 3) {
      data[currKey] = currValue;
    } else if (state == 1) {
      data[currKey] = null;
    }
    return data;
  }

  /// Get metadata filtered by the elements in args. For example, can pass
  /// ('INFO', 'DP') to only get info on an metadata tag that was like
  /// "##INFO=<ID=DP,...>"
  /// @param  {...string} args - List of metadata filter strings.
  ///
  /// @returns {any} An object, string, or number, depending on the filtering
  getMetadata(List args) {
    var filteredMetadata = this.metadata;
    for (var i = 0; i < args.length; i += 1) {
      filteredMetadata = filteredMetadata[args[i]];
      if (filteredMetadata == null) return filteredMetadata;
    }
    return filteredMetadata;
  }

  /// Parse a VCF line into an object like { CHROM POS ID REF ALT QUAL FILTER
  /// INFO } with SAMPLES optionally included if present in the VCF
  /// @param {string} line - A string of a line from a VCF. Supports both LF and
  /// CRLF newlines.
  parseLine(String line) {
    // eslint-disable-next-line no-param-reassign
    line = line.trim();
    if (line.length == 0) return null;
    var currChar = 0;
    for (var currField = 0; currChar < line.length; currChar += 1) {
      if (line[currChar] == '\t') {
        currField += 1;
      }
      if (currField == 9) {
        // reached genotypes, rest of fields are evaluated lazily
        break;
      }
    }
    var fields = line.substring(0, currChar).split('\t');
    var rest = line.substring(currChar + 1);
    Map variant = {
      'CHROM': fields[0],
      'POS': num.tryParse(fields[1]),
      'ID': fields[2] == '.' ? null : fields[2].split(';'),
      'REF': fields[3],
      'ALT': fields[4] == '.' ? null : fields[4].split(','),
      'QUAL': fields[5] == '.' ? null : double.tryParse(fields[5]),
    };
    if (fields[6] == '.') {
      variant['FILTER'] = null;
    } else if (fields[6] == 'PASS') {
      variant['FILTER'] = 'PASS';
    } else {
      variant['FILTER'] = fields[6].split(';');
    }
    if (this.strict && fields[7] == null) {
      throw Exception("no INFO field specified, must contain at least a '.' (turn off strict mode to allow)");
    }
    Map info = fields[7] == null || fields[7] == '.' ? {} : this._parseKeyValue(fields[7]);
    info.keys.forEach((key) {
      var items;
      if (info[key]) {
        items = info[key].split(',');
        items = items.map((val) => val == '.' ? null : val);
      } else
        items = info[key];
      var itemType = this.getMetadata(['INFO', key, 'Type']);
      if (itemType != null) {
        if (itemType == 'Integer' || itemType == 'Float') {
          items = items.map((val) {
            return num.tryParse(val);
          });
        } else if (itemType == 'Flag') {
          if (info[key])
            // eslint-disable-next-line no-console
            print('Info field ${key} is a Flag and should not have a value (got value ${info[key]})');
          else
            items = true;
        }
      }
      info[key] = items;
    });
    variant['INFO'] = info;

    // if this has SVTYPE=BND, parse ALTS for breakend descriptions
    if (variant['ALT'] && info != null && info['SVTYPE'] && info['SVTYPE'][0] == 'BND') {
      variant['ALT'] = variant['ALT'].map(this._parseBreakEnd);
    }

    // This creates a closure that allows us to attach "SAMPLES" as a lazy
    // attribute

    //  Variant(stuff) {
    // Object.assign(this, stuff);
    // }

    Map _variant = {
      ...variant,
      'SAMPLES': _parseGenotypes(fields[8], rest),
    };
    return _variant;
  }

  _parseBreakEnd(String breakEndString) {
    List<String> tokens = breakEndString.split(RegExp('[[\]]'));
    if (tokens.length > 1) {
      var parsed = BreakEnd();
      parsed.MateDirection = breakEndString.contains('[') ? 'right' : 'left';
      for (var i = 0; i < tokens.length; i += 1) {
        var tok = tokens[i];
        if (tok != null) {
          if (tok.contains(':')) {
            // this is the remote location
            parsed.MatePosition = tok;
            parsed.Join = parsed.Replacement != null ? 'right' : 'left';
          } else {
            // this is the local alteration
            parsed.Replacement = tok;
          }
        }
      }
      return parsed.toString();
    }
    // if there is not more than one token, there are no [ or ] characters,
    // so just return it unmodified
    return breakEndString;
  }

  _parseGenotypes(String _formatKeys, String _rest) {
    // eslint-disable-next-line no-param-reassign
    List<String> rest = _rest.split('\t');
    var genotypes = {};
    // eslint-disable-next-line no-param-reassign
    List formatKeys = _formatKeys.split(':');
    int index = 0;
    this.samples.forEach((sample) {
      genotypes[sample] = {};
      formatKeys.forEach((key) {
        genotypes[sample][key] = null;
      });
      int formatIndex = -1;
      rest[index].split(':').forEach((formatValue) {
        formatIndex++;
        var thisValue;
        if (formatValue == '' || formatValue == '.' || formatValue == null) {
          thisValue = null;
        } else {
          thisValue = formatValue.split(',').map((val) => (val == '.' ? null : val));
          var valueType = this.getMetadata(['FORMAT', formatKeys[formatIndex], 'Type']);
          if ((valueType == 'Integer' || valueType == 'Float') && thisValue) {
            thisValue = thisValue.map((val) => num.tryParse(val));
          }
        }
        genotypes[sample][formatKeys[formatIndex]] = thisValue;
      });
      index++;
    });
    return genotypes;
  }
}

class Variant {}
